From: June 2025

Transputer Emulation - Direct Instructions

This is a first part of working through a transputer emulator. I'm specifically aiming for the INMOS T800, which is the floating point model. The transputer architecture draws from RISC and parallel processing notions. Compared to work I've done on contemporary Intel CPUs, the design principles are a lot clearer and less mired in legacy decissions. The primary features of the T800 are a 32-bit word-size, stack-based register storage and quick interrupts. Much of the information here is pulled from: https://www.transputer.net/iset/pdf/transbook.pdf

The register stack consists of three registers labeled A, B and C. The stack can be pushed, which moves B into C, A into B and a new value is placed in A. The original contents of C is discarded. The stack can also be read and modified. In rust, I can create a general use struct which supports these operations:

pub const STACK_SIZE: usize = 3;



pub struct Stack{

    reg: Rc<RefCell<[i32; STACK_SIZE]>>

}



impl Stack{

    pub fn new() -> Self{

        Self{

            reg: Rc::new(RefCell::new([0; STACK_SIZE]))

        }

    }

    pub fn push(&mut self, value: i32){

        // Push values up and insert new value

        let mut c = self.reg.borrow_mut();

        for i in (1..STACK_SIZE).rev(){

            // C <= B, B <= A

            c[i] = c[i - 1];

        }

        // A = value

        c[0] = value;

    }

    pub fn get(&self, index: usize) -> i32{

        self.reg.borrow()[index].clone()

    }



    pub fn set(&self, index: usize, value: i32){

        self.reg.borrow_mut()[index] = value;

    }

}

I'm using for the Rc<RefCell<>> wrapper on the registers, which is to allow shared state between different processes. An interrupt process will use the same register space (which means that the state must be restored exiting the interrupt).

The processor also has to interface with memory. The memory struct has to provide methods for reading and writing, while having a shared state. At some point I may have to add some bus ordering, but the general structure is:

pub struct Mem{

    contents: Shared Container

}



impl Clone for Mem{

    fn clone(&self) -> Self {

        Self{

            // Pointer to the same underlying contents

            contents: self.contents.clone()

        }

    }

}



impl Mem{

    pub fn new() -> Self{

        Self{

            contents: New Shared container

        }

    }



    pub fn write(&mut self, address: i32, value: i32){

        // write value to address

    }



    pub fn read(&self, address: i32) -> i32{

        // return value at address

    }

}

For my current setup I am using a HashMap to have a lookup table of valid addresses. I will likely move this to a vec which dynamically grows with the stack size (well INMOS calls it a workspace). Or it can be allocated all at once for a given instance of the emulator. As long as it can provide read, write and clone methods it is arbitary.

Now I can run processes. The transputer has 15 direct instructions and then 16 "indirect" instructions. Direct instructions are the most common instructions that INMOS observed, and are given priority with a shorter instruction length. Indirect instructions are prefixed with 0xF and are a wider variety of lesser used instructions. I am implementing the indirect instructions later, so I'm going to look at the direct ones first. Each direct instruction consists of a 4 bit prefix and a 4 bit operand.

Opcode Mnemonic Description Operation
0x0X j jump Adds operand to the program pointer. This also deprioritzes the current processes, allowing other processses to run.
0x1X ldlp load local pointer Push the value of the workspace pointer (stack pointer) + 4*operand into the register stack.
0x2X pfix prefix Set the lower 4 bits of the operand register to the operand, and then shift the operand register up 4 bits. Allows the operand register to have larger values.
0x3X ldnl load non-local Loads a value from memory pointed by A + 4*operand
0x4X ldc load constant Pushes the value in the operand register into the register stack
0x5X ldnlp local non-local pointer Sets the value of A to A+4*operand
0x6X nfix negative prefix Sets the lower 4 bits of the operand register to the operand, then inverts the register, then shifts up by 4
0x7X ldl load local Loads a value at stack pointer + 4*operand from memory. Pushes that value into the register stack.
0x8X adc add constant Adds the contents of register A to the operand register
0x9X call call Stores the contents of the register stack and the program counter into stack memory, and then jumps to a location of PC+operand
0xAX cj conditional jump If the value of register A is 0, then jump to an offset set by the operand register
0xBX ajw adjust workspace Allocates (decrements) or unallocates (increments) the stack
0xCX eqc equals constant If A == Operand, push 1 into the register stack, otherwise push 0
0xDX stl store local Write the value of register A into the address set by the stack pointer + 4*operand
0xEX stnl store non-local Write the value of register B into the address set by register A+4*operand
0xFX opr indirect instruction Additional operations

I'm not going to go through every one of these, but I want to highlight a few examples. I'll start with a simple instruction, ldc, this pushes the operand register into the register stack. I start with a test case.

#[test]

fn load_constant(){

    let mut p = Proc::new(Mem::new());

    p.run(DirectOp::LDC, 5);

    assert!(p.peek(0) == 5); // 5 has been pushed into stack

    p.run(DirectOp::LDC, 10);

    assert!(p.peek(0) == 10); // 10 has been pushed into stack

    assert!(p.peek(1) == 5); // register B now has the previous value of A

}

The peek method is a debug method I added to the processor to check the value of the registers.

I can set up my processor with registers and and flags.

pub struct Proc{

    // Registers

    stack: Stack, // Register stack

    operand: i32, // operand register

    workspace: i32, // stack pointer

    // Flags

    error: bool,

    idle : bool, // can another process run

    // Shared ram

    mem: Mem

}

The load constant method is then:

fn ldc(&mut self, value: i32){

    // Set the lower 4 bits of the operand register to the operand

    self.operand = mask4(self.operand) + value;



    // push value onto register stack

    self.stack.push(self.operand);



    // Clear the operand register

    self.operand = 0;

}

This is not so bad. The adc instruction requires a few more steps. The direct way of writing an add instruction is this:

fn adc(&mut self, value: i32){



    self.operand = mask4(self.operand) + value;



    // Add register a to the operand register

    let result = self.stack.get(0) + self.operand;

    

    // Set register A to the result

    self.stack.set(0, result);



    // Clear operand register

    self.operand = 0;

}

This will function for most cases, however the overflow case has to be handled. For standard rust adds, the program will panic with an overflow error. This means my emulator will crash. What I want to happen is the processor continues unabated, it just sets its own internal error flag. So I have to use the rust methods checked_add and wrapping_add. checked_add returns None if the value overflows, and wrapping_add ignores overflows and just discards the carry bit when adding. The adc instruction now looks like this:

fn adc(&mut self, value: RTYPE){

    self.operand = mask4(self.operand) + value;



    let a = self.stack.get(0);



    // Add while checking for overflow

    if let Some(result) = a.checked_add(self.operand){

        // Add did not overflow

        self.stack.set(0, result);

    }

    else{

        // Wrap and add, and set error flag

        self.stack.set(0, a.wrapping_add(self.operand));

        self.error = true;

    }



    self.operand = 0;

}

This now handles normal addition, and error flags.

Finally I want to show the call instruction, because it's got a few steps. First it stores the register contents into the memory stack and then the program counter. Then it offsets the program counter.

fn call(&mut self, value: RTYPE){

    // Pushes C, B, A and instruction pointer to workspace

    self.mem.write(self.workspace, self.stack.get(2));

    self.mem.write(self.workspace - 4, self.stack.get(1));

    self.mem.write(self.workspace - 8, self.stack.get(0));

    self.mem.write(self.workspace - 12, self.pc);



    // update stack pointer

    self.workspace = self.workspace - 12;



    // Jumps to relative location

    self.operand = mask4(self.operand) + value;

    self.pc = self.pc + self.operand;

    self.operand = 0;

}

This operates on both the program counter and workspace pointer register. This is used to call subroutines, and the register staet can be recalled from the location in stack memory. In assembly using this would look something like this.

# Main

ldc 4 # Set A to constant value

stl 0 # place contents of A into stack

call decrement # Save state (4 words) and jump to decrement subroutine



# Assembler symbolic expressions

parameter = 5

locals = 0



decrement: # Symbolic label

ajw -locals # Allocate space for local variables (none here)

ldl parameter # Load parameter that is 1 above the saved state



adc -1 # Subtract one from the loaded parameter

stl parameter # Save parameter back to memory



ajw locals # Deallocate space for local variables

ret # Indirect return function state



Because the saved state takes 4 words, the subroutine can use arguments starting at 5 below the local workspace pointer. I'm going to go through the indirect instructions later, but wanted to get started making things happen. I have tests for most of the basic instructions.